iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

前面還沒寫完,會缺點東西,晚點補上

這篇主要是著重在 Shader 流程的介紹與相關程式,會挑比較重要的部分來說

Input-Assembler(IA) / Output-Merget(OM)

分別為處理 Vertex Index 的輸出(IA)與最後結果的輸出(OM)

Vertex Shader(VS)

用來描繪圖像在畫面上的樣子

Hull Shader(HS)

Hull Shader 會擁有具體 Tesselation 切割所需要的設定,譬如輸入 Patch data ,輸出 patch data,跟 patch constant data 等等。

Domain Shader(DS)

Tesselation 分割後的結果、實際計算他的 3D 座標

Geometry Shader(GS)

現在因為此比較耗能的關係,實作上比較不會出現。

Pixel Shader(PS)

最基本的 Pixel Shader 有以下的幾點

  1. material顏色跟 texture 顏色的調查
  2. shading 眼色深淺的計算
  3. 深淺顏色的合成

fragment shader 是 Pixel Shader 前的一個步驟,不過把他們視為一起的也可以

當然實際上的過程不需要這麼多的步驟

只需要走 IA→VS→RS→PS 就好

實際程式

//vertex_buffer.h

#pragma once
#include <directx/d3d12.h>
#include <wrl/client.h>

class VertexBuffer
{
public:
    void init(int count, int stride);
    void copy(const void* srcVertices);

    [[nodiscard]]
    const D3D12_VERTEX_BUFFER_VIEW& getView() const 
    {
        return m_vertexBufferView;
    }

    [[nodiscard]]
    UINT getStrideInBytes() const
    {
        return m_vertexBufferView.StrideInBytes;
    }

    [[nodiscard]]
    UINT getSizeInBytes() const {
        return m_vertexBufferView.SizeInBytes;
    }

    [[nodiscard]]
    ID3D12Resource* getID3DResourceAddress() const {
        return m_vertexBuffer.Get();
    }

  private:
    Microsoft::WRL::ComPtr<ID3D12Resource> m_vertexBuffer;
    D3D12_VERTEX_BUFFER_VIEW m_vertexBufferView;
    int m_sizeInBytes = 0;
};
//vertex_buffer.cpp

#include <cstring>              
#include <directx/d3dx12_core.h> 
#include <limits>                
#include <stdexcept>             

#include "graphics_engine.h"
#include "vertex_buffer.h"

    // 建立指定頂點數量與單一頂點大小的 Upload Heap Vertex Buffer
    void VertexBuffer::init(int count, int stride)
{
    // 確認頂點數量與單一頂點大小都大於 0
    if (count <= 0 || stride <= 0)
        throw std::invalid_argument("VertexBuffer: Count and stride must be positive.");

    // 確認 count * stride 不會超出 int 可表示的範圍
    if (count > std::numeric_limits<int>::max() / stride)
        throw std::overflow_error("VertexBuffer: Buffer size exceeds the supported range.");

    // 確認 GraphicsEngine 與 Direct3D 12 Device 已完成初始化
    if (g_graphicsEngine == nullptr || g_graphicsEngine->getD3DDevice() == nullptr)
        throw std::runtime_error("VertexBuffer: Graphics device is not initialized.");

    // 計算 Vertex Buffer 需要的總 Byte 數
    m_sizeInBytes = count * stride;

    // 建立 Upload Heap 的 Heap Properties
    const auto heapProperties = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);

    // 建立指定大小的 Buffer Resource 描述
    const auto resourceDescription = CD3DX12_RESOURCE_DESC::Buffer(m_sizeInBytes);

    // 建立實際的 Direct3D 12 Vertex Buffer Resource
    const HRESULT result = g_graphicsEngine->getD3DDevice()->CreateCommittedResource(
        &heapProperties, D3D12_HEAP_FLAG_NONE, &resourceDescription, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
        IID_PPV_ARGS(&m_vertexBuffer));

    // 如果 Vertex Buffer Resource 建立失敗就丟出例外
    if (FAILED(result))
        throw std::runtime_error("VertexBuffer: Failed to create the Direct3D resource.");

    // 設定 Vertex Buffer 在 GPU 記憶體中的起始位址
    m_vertexBufferView.BufferLocation = m_vertexBuffer->GetGPUVirtualAddress();

    // 設定 Vertex Buffer 的總 Byte 數
    m_vertexBufferView.SizeInBytes = static_cast<UINT>(m_sizeInBytes);

    // 設定每一個 Vertex 的 Byte 大小
    m_vertexBufferView.StrideInBytes = static_cast<UINT>(stride);
}

// 將 CPU 端的 Vertex 資料複製到 Vertex Buffer
void VertexBuffer::copy(const void* srcVertices)
{
    // 確認 Vertex Buffer 已經先完成初始化
    if (m_vertexBuffer == nullptr)
        throw std::logic_error("VertexBuffer: init must be called before copy.");

    // 確認來源 Vertex 資料不是空指標
    if (srcVertices == nullptr)
        throw std::invalid_argument("VertexBuffer: Source vertices must not be null.");

    // 保存 Map 後取得的 CPU 可寫入記憶體位址
    void* destination = nullptr;

    // 表示 CPU 不會讀取這塊 GPU Resource 的任何範圍
    const D3D12_RANGE readRange{0, 0};

    // 將 Vertex Buffer 映射成 CPU 可以存取的記憶體
    const HRESULT result = m_vertexBuffer->Map(0, &readRange, &destination);

    // 如果 Map 失敗就丟出例外
    if (FAILED(result))
        throw std::runtime_error("VertexBuffer: Failed to map the Direct3D resource.");

    // 將 CPU 端 Vertex 資料複製到 Vertex Buffer
    std::memcpy(destination, srcVertices, static_cast<std::size_t>(m_sizeInBytes));

    // 結束 CPU 對 Vertex Buffer 的映射
    m_vertexBuffer->Unmap(0, nullptr);
}

//index_buffer.h

#pragma once
#include <cstdint>
#include <directx/d3d12.h>
#include <wrl/client.h>

class IndexBuffer
{
public:
    void init(int count, int stride);

    void copy(const std::uint16_t* srcIndices);

    void copy(const std::uint32_t* srcIndices);

    [[nodiscard]]
    const D3D12_INDEX_BUFFER_VIEW& getView() const
    {
        return m_indexBufferView;
    }

    [[nodiscard]]
    UINT getCount() const
    {
        return static_cast<UINT>(m_count);
    }
private:
    Microsoft::WRL::ComPtr<ID3D12Resource> m_indexBuffer = nullptr;
    D3D12_INDEX_BUFFER_VIEW m_indexBufferView{};
    int m_count = 0;
    int m_strideInBytes = 0;
    int m_sizeInBytes = 0;
};
//index_buffer.cpp

#include "index_buffer.h"
#include "graphics_engine.h"
#include <limits>
#include <stdexcept>

    // 建立指定 Index 數量與 Index 大小的 Upload Heap Index Buffer
    void IndexBuffer::init(int count, int stride)
{
    // 確認 Index 數量大於 0 且單一 Index 大小只能是 2 或 4 Bytes
    if (count <= 0 || (stride != 2 && stride != 4))
        throw std::invalid_argument("IndexBuffer: Count must be positive and stride must be 2 or 4 bytes.");

    // 確認 count * stride 不會超出 int 可表示的範圍
    if (count > std::numeric_limits<int>::max() / stride)
        throw std::overflow_error("IndexBuffer: Buffer size exceeds the supported range.");

    // 確認 GraphicsEngine 與 Direct3D 12 Device 已完成初始化
    if (g_graphicsEngine == nullptr || g_graphicsEngine->getD3DDevice() == nullptr)
        throw std::runtime_error("IndexBuffer: Graphics device is not initialized.");

    // 保存 Index 的數量
    m_count = count;

    // 保存每個 Index 使用的 Byte 數
    m_strideInBytes = stride;

    // 計算整個 Index Buffer 的總 Byte 數
    m_sizeInBytes = count * stride;

    // 取得 Direct3D 12 Device
    auto d3dDevice = g_graphicsEngine->getD3DDevice();

    // 建立 Upload Heap 的 Heap Properties
    auto heapProp = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);

    // 建立指定大小的 Buffer Resource 描述
    auto rDesc = CD3DX12_RESOURCE_DESC::Buffer(m_sizeInBytes);

    // 建立實際的 Direct3D 12 Index Buffer Resource
    const HRESULT result =
        d3dDevice->CreateCommittedResource(&heapProp, D3D12_HEAP_FLAG_NONE, &rDesc, D3D12_RESOURCE_STATE_GENERIC_READ,
                                           nullptr, IID_PPV_ARGS(&m_indexBuffer));

    // 如果 Index Buffer Resource 建立失敗就丟出例外
    if (FAILED(result))
        throw std::runtime_error("IndexBuffer: Failed to create the Direct3D resource.");

    // 設定 Index Buffer 在 GPU 記憶體中的起始位址
    m_indexBufferView.BufferLocation = m_indexBuffer->GetGPUVirtualAddress();

    // 根據 Index 大小設定成 16 位元或 32 位元格式
    m_indexBufferView.Format = stride == 2 ? DXGI_FORMAT_R16_UINT : DXGI_FORMAT_R32_UINT;

    // 設定 Index Buffer 的總 Byte 數
    m_indexBufferView.SizeInBytes = m_sizeInBytes;
}

// 將 16 位元 Index 資料複製到 Index Buffer
void IndexBuffer::copy(const std::uint16_t* srcIndices)
{
    // 確認 Index Buffer 已初始化且格式為 16 位元
    if (m_indexBuffer == nullptr || m_strideInBytes != 2)
        throw std::logic_error("IndexBuffer: A 16-bit buffer must be initialized before copy.");

    // 確認來源 Index 資料不是空指標
    if (srcIndices == nullptr)
        throw std::invalid_argument("IndexBuffer: Source indices must not be null.");

    // 保存 Map 後取得的 16 位元 CPU 可寫入記憶體位址
    std::uint16_t* pData = nullptr;

    // 表示 CPU 不會讀取這塊 Resource 的任何範圍
    const D3D12_RANGE readRange{0, 0};

    // 將 Index Buffer 映射成 CPU 可以存取的記憶體
    const HRESULT result = m_indexBuffer->Map(0, &readRange, reinterpret_cast<void**>(&pData));

    // 如果 Map 失敗就丟出例外
    if (FAILED(result))
        throw std::runtime_error("IndexBuffer: Failed to map the Direct3D resource.");

    // 將所有 16 位元 Index 資料逐筆複製到 Buffer
    for (int i = 0; i < m_count; i++)
    {
        pData[i] = srcIndices[i];
    }

    // 結束 CPU 對 Index Buffer 的映射
    m_indexBuffer->Unmap(0, nullptr);
}

// 將 32 位元 Index 資料複製到 Index Buffer
void IndexBuffer::copy(const uint32_t* srcIndices)
{
    // 確認 Index Buffer 已初始化且格式為 32 位元
    if (m_indexBuffer == nullptr || m_strideInBytes != 4)
        throw std::logic_error("IndexBuffer: A 32-bit buffer must be initialized before copy.");

    // 確認來源 Index 資料不是空指標
    if (srcIndices == nullptr)
        throw std::invalid_argument("IndexBuffer: Source indices must not be null.");

    // 保存 Map 後取得的 32 位元 CPU 可寫入記憶體位址
    uint32_t* pData = nullptr;

    // 表示 CPU 不會讀取這塊 Resource 的任何範圍
    const D3D12_RANGE readRange{0, 0};

    // 將 Index Buffer 映射成 CPU 可以存取的記憶體
    const HRESULT result = m_indexBuffer->Map(0, &readRange, reinterpret_cast<void**>(&pData));

    // 如果 Map 失敗就丟出例外
    if (FAILED(result))
        throw std::runtime_error("IndexBuffer: Failed to map the Direct3D resource.");

    // 將所有 32 位元 Index 資料逐筆複製到 Buffer
    for (int i = 0; i < m_count; i++)
    {
        pData[i] = srcIndices[i];
    }

    // 結束 CPU 對 Index Buffer 的映射
    m_indexBuffer->Unmap(0, nullptr);
}

//shader.h

#pragma once
#include <directx/d3dcommon.h>
#include <wrl/client.h>

class Shader
{
public:
    void loadPS(const char* filePath, const char* entryFuncName);
    void loadVS(const char* filePath, const char* entryFuncName);
    void loadCS(const char* filePath, const char* entryFuncName);

    [[nodiscard]]
    ID3DBlob* getCompiledBlob() const
    {
        return m_blob.Get();
    }

private:
    void load(const char* filePath, const char* entryFuncName,
        const wchar_t* shaderModel);

    Microsoft::WRL::ComPtr<ID3DBlob> m_blob; //編譯完成的 shader data
    bool m_isInited = false;
};
//shader.cpp
#include "shader.h"
#include <Windows.h>
#include <dxcapi.h>

#include <filesystem>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <vector>

    namespace
{
    // 設定 Vertex Shader 使用的 Shader Model 版本
    constexpr const wchar_t* g_vsShaderModelName = L"vs_6_4";

    // 設定 Pixel Shader 使用的 Shader Model 版本
    constexpr const wchar_t* g_psShaderModelName = L"ps_6_4";

    // 設定 Compute Shader 使用的 Shader Model 版本
    constexpr const wchar_t* g_csShaderModelName = L"cs_6_4";

    // 將輸入的 Shader 路徑轉換成可實際存取的完整檔案路徑
    std::filesystem::path resolveShaderPath(const char* filePath)
    {
        // 確認 Shader 檔案路徑有效
        if (filePath == nullptr || filePath[0] == '\0')
        {
            throw std::invalid_argument("Shader: A shader file path is required.");
        }

        // 計算 UTF-8 路徑轉成寬字元後需要的字元數量
        const int requiredCharacterCount = MultiByteToWideChar(CP_UTF8, 0, filePath, -1, nullptr, 0);

        // 如果路徑字串轉換需求計算失敗就丟出例外
        if (requiredCharacterCount == 0)
        {
            throw std::runtime_error("Shader: Failed to convert shader path.");
        }

        // 建立足夠大小的寬字元字串
        std::wstring wideFilePath(static_cast<std::size_t>(requiredCharacterCount), L'\0');

        // 將 UTF-8 Shader 路徑轉換成 UTF-16 寬字元字串
        if (MultiByteToWideChar(CP_UTF8, 0, filePath, -1, wideFilePath.data(), requiredCharacterCount) == 0)
        {
            throw std::runtime_error("Shader: Failed to convert shader path.");
        }

        // 移除轉換後字串尾端多出的 Null 字元
        wideFilePath.pop_back();

        // 將寬字元字串轉成 filesystem 路徑物件
        std::filesystem::path shaderPath(wideFilePath);

        // 如果已經是絕對路徑就直接回傳
        if (shaderPath.is_absolute())
        {
            return shaderPath;
        }

        // 建立用來保存執行檔路徑的字元陣列
        wchar_t executablePath[MAX_PATH]{};

        // 取得目前執行檔的完整路徑
        const DWORD executablePathLength = GetModuleFileNameW(nullptr, executablePath, _countof(executablePath));

        // 如果無法取得有效的執行檔路徑就丟出例外
        if (executablePathLength == 0 || executablePathLength == _countof(executablePath))
        {
            throw std::runtime_error("Shader: Failed to determine "
                                     "the executable directory.");
        }

        // 將相對 Shader 路徑接到執行檔所在資料夾後回傳
        return std::filesystem::path(executablePath).parent_path() / shaderPath;
    }

    // 將 UTF-8 字串轉換成 UTF-16 寬字元字串
    std::wstring convertUtf8ToWide(const char* text)
    {
        // 確認輸入文字不是空指標
        if (text == nullptr)
        {
            throw std::invalid_argument("Shader: Text must not be null.");
        }

        // 計算 UTF-8 字串轉換成寬字元後需要的字元數量
        const int requiredCharacterCount = MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0);

        // 如果字串轉換需求計算失敗就丟出例外
        if (requiredCharacterCount == 0)
        {
            throw std::runtime_error("Shader: UTF-8 conversion failed.");
        }

        // 建立足夠大小的寬字元字串
        std::wstring wideText(static_cast<std::size_t>(requiredCharacterCount), L'\0');

        // 將 UTF-8 字串實際轉換成 UTF-16
        if (MultiByteToWideChar(CP_UTF8, 0, text, -1, wideText.data(), requiredCharacterCount) == 0)
        {
            throw std::runtime_error("Shader: UTF-8 conversion failed.");
        }

        // 移除轉換後字串尾端多出的 Null 字元
        wideText.pop_back();

        return wideText;
    }

    // 如果 HRESULT 代表失敗就丟出指定的 runtime_error
    void throwIfFailed(HRESULT hr, const char* message)
    {
        // 判斷 Direct3D 或 DXC API 呼叫是否失敗
        if (FAILED(hr))
        {
            throw std::runtime_error(message);
        }
    }
}

// 從 HLSL 檔案讀取原始碼並使用 DXC 編譯成指定 Shader Model 的 DXIL
void Shader::load(const char* filePath, const char* entryFuncName, const wchar_t* shaderModel)
{
    // 確認 Shader Entry Function 名稱有效
    if (entryFuncName == nullptr || entryFuncName[0] == '\0')
    {
        throw std::invalid_argument("Shader: An entry function is required.");
    }

    // 確認 Shader Model 名稱有效
    if (shaderModel == nullptr || shaderModel[0] == L'\0')
    {
        throw std::invalid_argument("Shader: A shader model is required.");
    }

    // 取得 Shader 檔案的完整路徑
    const std::filesystem::path shaderPath = resolveShaderPath(filePath);

    // 將 Entry Function 名稱轉換成 DXC 使用的寬字元格式
    const std::wstring entryPoint = convertUtf8ToWide(entryFuncName);

    // 保存 DXC 工具介面
    Microsoft::WRL::ComPtr<IDxcUtils> utils;

    // 保存 DXC Shader Compiler 介面
    Microsoft::WRL::ComPtr<IDxcCompiler3> compiler;

    // 保存 DXC 預設 Include Handler
    Microsoft::WRL::ComPtr<IDxcIncludeHandler> includeHandler;

    // 建立 DXC Utils 物件
    throwIfFailed(DxcCreateInstance(CLSID_DxcUtils, IID_PPV_ARGS(&utils)), "Shader: Failed to create IDxcUtils.");

    // 建立 DXC Compiler 物件
    throwIfFailed(DxcCreateInstance(CLSID_DxcCompiler, IID_PPV_ARGS(&compiler)),
                  "Shader: Failed to create IDxcCompiler3.");

    // 建立處理 HLSL include 的預設 Include Handler
    throwIfFailed(utils->CreateDefaultIncludeHandler(&includeHandler), "Shader: Failed to create include handler.");

    // 以 Binary 模式開啟 HLSL Shader 檔案
    std::ifstream sourceFile(shaderPath, std::ios::binary);

    // 如果 HLSL 檔案開啟失敗就丟出例外
    if (!sourceFile)
        throw std::runtime_error("Shader: Failed to open HLSL file.");

    // 將整個 HLSL 檔案內容讀入 char 陣列
    const std::vector<char> sourceBytes{std::istreambuf_iterator<char>{sourceFile}, std::istreambuf_iterator<char>{}};

    // 如果讀取 HLSL 檔案過程發生錯誤就丟出例外
    if (sourceFile.bad())
        throw std::runtime_error("Shader: Failed while reading HLSL file.");

    // 如果 HLSL 檔案沒有內容就丟出例外
    if (sourceBytes.empty())
        throw std::runtime_error("Shader: HLSL file is empty.");

    // 建立提供給 DXC 的 Shader Source Buffer
    DxcBuffer sourceBuffer{};

    // 指向 HLSL 原始碼資料
    sourceBuffer.Ptr = sourceBytes.data();

    // 設定 HLSL 原始碼資料大小
    sourceBuffer.Size = sourceBytes.size();

    // 指定 HLSL 原始碼使用 UTF-8 編碼
    sourceBuffer.Encoding = DXC_CP_UTF8;

    // 建立傳給 DXC Compiler 的編譯參數
    std::vector<LPCWSTR> arguments{shaderPath.c_str(),

                                   L"-E",
                                   entryPoint.c_str(),

                                   L"-T",
                                   shaderModel,

                                   L"-HV",
                                   L"2021",

                                   L"-Zpr"};

#ifdef _DEBUG
    // Debug 模式加入 Shader Debug 資訊
    arguments.push_back(L"-Zi");

    // 將 Debug 資訊直接嵌入編譯後的 Shader
    arguments.push_back(L"-Qembed_debug");

    // Debug 模式停用 Shader 最佳化
    arguments.push_back(L"-Od");
#else
    // Release 模式啟用最高等級 Shader 最佳化
    arguments.push_back(L"-O3");
#endif

    // 保存 DXC 編譯結果
    Microsoft::WRL::ComPtr<IDxcResult> result;

    // 使用 DXC 編譯 HLSL Shader
    throwIfFailed(compiler->Compile(&sourceBuffer, arguments.data(), static_cast<UINT32>(arguments.size()),
                                    includeHandler.Get(), IID_PPV_ARGS(&result)),
                  "Shader: DXC compiler invocation failed.");

    // 保存 DXC 編譯器輸出的錯誤與警告訊息
    Microsoft::WRL::ComPtr<IDxcBlobUtf8> errorBlob;

    // 從編譯結果中取得錯誤與警告訊息
    throwIfFailed(result->GetOutput(DXC_OUT_ERRORS, IID_PPV_ARGS(&errorBlob), nullptr),
                  "Shader: Failed to retrieve compiler messages.");

    // 如果有編譯訊息就輸出到 Visual Studio Debug Output
    if (errorBlob != nullptr && errorBlob->GetStringLength() > 0)
    {
        OutputDebugStringA(errorBlob->GetStringPointer());
    }

    // 保存實際的 Shader 編譯結果狀態
    HRESULT compileStatus = S_OK;

    // 取得 Shader 是否成功編譯的 HRESULT
    throwIfFailed(result->GetStatus(&compileStatus), "Shader: Failed to retrieve compile status.");

    // 如果 Shader 編譯失敗就整理錯誤訊息並丟出例外
    if (FAILED(compileStatus))
    {
        std::string message = "Shader: Failed to compile ";

        // 將發生編譯錯誤的 Shader 路徑加入訊息
        message += shaderPath.string();

        // 如果 DXC 有詳細錯誤資訊就加入例外訊息
        if (errorBlob != nullptr && errorBlob->GetStringLength() > 0)
        {
            message += "\n";
            message += errorBlob->GetStringPointer();
        }

        throw std::runtime_error(message);
    }

    // 保存成功編譯後產生的 DXIL Blob
    Microsoft::WRL::ComPtr<IDxcBlob> compiledBlob;

    // 從 DXC 編譯結果中取得 DXIL Object
    throwIfFailed(result->GetOutput(DXC_OUT_OBJECT, IID_PPV_ARGS(&compiledBlob), nullptr),
                  "Shader: Failed to retrieve DXIL.");

    // 將 IDxcBlob 轉換成目前 Shader 類別保存的 ID3DBlob
    throwIfFailed(compiledBlob.As(&m_blob), "Shader: Failed to expose DXIL as ID3DBlob.");

    // 標記 Shader 已成功完成初始化
    m_isInited = true;
}

// 使用 Vertex Shader 的 Shader Model 編譯指定 HLSL Entry Function
void Shader::loadVS(const char* filePath, const char* entryFuncName)
{
    load(filePath, entryFuncName, g_vsShaderModelName);
}

// 使用 Pixel Shader 的 Shader Model 編譯指定 HLSL Entry Function
void Shader::loadPS(const char* filePath, const char* entryFuncName)
{
    load(filePath, entryFuncName, g_psShaderModelName);
}

// 使用 Compute Shader 的 Shader Model 編譯指定 HLSL Entry Function
void Shader::loadCS(const char* filePath, const char* entryFuncName)
{
    load(filePath, entryFuncName, g_csShaderModelName);
}

參考資料

GitHub - shoeisha-books/hlsl-grimoire-sample: 『HLSL シェーダーの魔導書』(ISBN978-4-7981-6428-1)のサンプルファイル

drawio 畫範例圖


上一篇
Day 8 :Graphics Pipeline 介紹(4) - ResourceBarrier Fence Frame
下一篇
Day 10:Graphics Pipeline 介紹(6) - 輸出三角形
系列文
因為 AI 看不懂老舊程式,只好乖乖從零開始學 DirectX 12 與 HLSL13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言